iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0

某天早上翻持倉狀態檔,一個數字讓我愣住:MU(美光)的現價欄寫著 $864

那陣子 MU 的實際股價在一百六十幾美金附近(此為當時價位;重點不在絕對數字,而在 $864 是實價的五倍多)。$864 讓我的持倉檔理直氣壯地顯示這檔股票單日暴漲 400%,未實現損益一夜之間多了一輛機車的錢。

第一反應當然是爽。第二反應是冷汗:**如果我沒有親眼看到這個數字,晨報直接把它寫成「MU 大漲、獲利了結時機」呢?**如果我半夢半醒地看了報告,順手掛了一張賣單呢?一套每天自動運轉、我越來越信任的系統,餵了我一筆毒數據。

今天講這次事故的完整覆盤,以及事後補上的資料防呆層。這篇沒有新技術,但有一層比一層深的兩個教訓:先是「外部資料會餵你毒數據,你得加防呆」——這層大家都想得到;更難堪的是後半段那個續集:**連你加的那道防呆,本身都會變成一個永遠不會解除的故障。**第二層才是我真正想講的,前面的 $864 只是把你帶到那裡的入場券。

事故經過:毒數據是怎麼流進來的

回溯 log,鏈路是這樣的:

  1. 凌晨快取更新時,那個免費源對 MU 回了一個「看起來成功」的回應——HTTP 200、JSON 結構正常、欄位齊全。
  2. 但回應裡那個價格欄位是個明顯異常的值(事後推測是該源當下的資料故障,或我在幾個相近欄位間取錯了欄——同一個回應裡「前收盤」和「即時價」語意不同,取錯就差很多)。
  3. 我的正規化層只轉換了欄位名稱,沒有驗證數值合理性。$864 就這樣穿著合法 JSON 的外衣,一路寫進快取。
  4. 下游的持倉同步腳本忠實地拿它算損益——腳本沒有錯,錯的是輸入。

注意這裡最陰險的一點:每一層都「正常運作」。API 有回應、JSON 能解析、腳本沒噴錯、報告準時送達。整條 pipeline 綠燈通行,卻運送著一筆毒貨。傳統的「有沒有報錯」監控在這種場景下完全失效——這不是可用性問題,是資料品質問題。

動手做:價格合理區間防護

https://ithelp.ithome.com.tw/upload/images/20260817/201828651iJfB5UkcD.png

修復的核心思路很樸素:每一筆新價格,都要跟舊價格對質。

股價是連續性很強的數據——正常情況下,一天的波動極少超過 ±30%(就算真的漲停跌停,也遠到不了 400%)。所以在快取寫入前,加一道 sanity check:

const MAX_DAILY_CHANGE = 0.30;   // 單日容忍波動 ±30%

function sanitize(symbol, newQuote, oldQuote) {
  if (!oldQuote?.price) return newQuote;     // 沒有舊值可比,先放行

  const change = Math.abs(newQuote.price - oldQuote.price) / oldQuote.price;

  if (change > MAX_DAILY_CHANGE) {
    // 超出合理區間:拒收新值,保留舊值 + 標記 stale + 記錄事件
    log.warn(`${symbol} 價格異常: ${oldQuote.price} → ${newQuote.price},已拒收`);
    return { ...oldQuote, stale: true, reason: "PRICE_OUT_OF_RANGE" };
  }
  return newQuote;
}

幾個設計決策值得展開:

**超界時保留舊值,而不是寫入 null。**昨天講過的原則在這裡再次適用:一個「舊但大致正確」的價格,比「空值」或「新但離譜」的價格有用得多。下游拿舊值頂多是資訊延遲,拿到 null 會連損益都算不出來,拿到假值則會做錯決策——三種失敗模式裡,舊值的傷害最小。防呆設計的本質是選一種最便宜的失敗方式。

**閾值定 30% 而不是 10%。**太緊會誤殺:財報日的暴漲暴跌、股票分割(split)都是真實會發生的大波動。我的取捨是「寧可放過真的暴漲,不可錯殺成常態誤報」——因為誤報多了人會麻痺(Day 25 有一個更慘的誤報故事)。至於股票分割這種合法的價格腰斬,我會在 log 看到那筆異常,人工確認後更新成本基準即可,一年遇不到幾次。

拒收要留痕。每次拒收都寫進 log(老實說目前只到 log 這一層——單檔拒收不會主動推播給我,只有整支 job 連續失敗才會告警;「拒收也發推播」還是個待補的洞)。防呆不是把異常吞掉當沒事,而是把異常從「自動流入決策」降級成「留下痕跡、等人來看一眼」

一個誠實的補充:在後來實際跑的版本裡,這道「相對變化率」檢查其實被我降成了警示,而不是硬攔截——變化率過大時往 log 記一筆等我看,但不擋它寫入。真正會拒收的是下一節續集要講的「每檔絕對區間 {min, max}」。兩道檢查的嚴重程度是分開的:**相對變化率負責『提醒我有異狀』,絕對區間負責『擋住明顯的髒資料』。**上面的偽碼把兩件事合在一起寫,是為了先把「拿新值跟舊值對質」這個核心觀念講清楚;真實系統裡它們是一軟一硬的兩層。

stale 標記要一路傳到報告層

防護上線後我以為完事了,結果發現一個漏洞:快取裡的 stale 標記,下游報告根本沒在看。價格被攔下、舊值加註 stale,但晨報只叫 LLM「讀持倉檔寫摘要」,於是它拿著幾天前的舊價侃侃而談「今日走勢平穩」——我以為看到的是今天,其實是上週,這跟假數據一樣誤導。

修法是讓 stale 一路顯性化到人眼前:快取標 stale +原因碼 → 持倉檔備註「⚠️ 舊價」→ 晨報 prompt 在整體快取超過 8 小時時標註「⚠️ 數據可能過時」→ 健檢 job 快取超 25 小時未更新就告警。一句話:資料的「新鮮度」本身就是資料,要跟著數據一起流動;吞掉 stale 標記的報告,比沒有報告更危險。

續集:這道防呆,自己變成了故障

上面那套防護上線後,我一直很滿意。直到某天翻投資報告,發現一個更難堪的東西——而這次的兇手是我自己加的防呆。

現在把上一節預告的第二道機制講清楚——除了「跟舊值比變化率」,還有每檔股票各自的絕對合理區間。因為變化率擋不住一種情況——如果爬蟲連續兩天都抓到同一個錯誤頁面,兩個錯值之間的「變化率」是 0,完美通過檢查。所以每檔各自寫死一組 {min, max},用來擋「正數但錯 10 倍」這種單位位移。

問題出在其中一檔的 max

那檔股票當時股價約 2,380,我的觀察清單設了一條規則:跌到 2,650 以下就提醒我可以進場(它當時已經在門檻內)。而我隨手把它的 max 設成 2,500

你可能已經看出來了:max 比觸發門檻還低。

後來股票漲了。漲破 2,500 的那一刻,防呆層盡責地判定「這個價格不合理」,拒收,保留舊值。於是:

  • 快取永遠凍在 2,380
  • 系統每天忠實地比對:2,380 ≤ 2,650 → 「已觸發,可進場」
  • 而真實股價早已漲過 2,650訊號早該解除、根本不該買

這比原本的 $864 事故更糟糕,因為 $864 那次是一次性的錯值,看到就會覺得怪;這次是一個永遠不會解除的錯誤訊號,而且它每天都在「正確地」重複自己。快取沒有標 stale(因為它「成功保留了舊值」),報告沒有警告,監控全綠。

修法本身很簡單——把 max 調到門檻之上並留足上漲空間。但我另外做了一件更重要的事:把這個關係寫成一條測試

那條測試不檢查任何具體數字,它檢查一個不變量

每一檔的 max,都必須高於它在觀察清單裡的觸發門檻。

這樣寫的好處是,未來我調整任何門檻或邊界,只要不小心讓兩者交叉,測試會立刻紅燈——而不是等到某天股價漲上去、我做錯一次決策才發現。

這件事給我的教訓,比原本那篇的三條收穫都深:

一道防呆的邊界,如果比它要保護的訊號還緊,它不會保護你,它會製造一個永遠正確的錯誤。

邊界的用途是擋「單位位移」這種明顯的髒資料,不是擋真實行情。我當初設 2,500 的時候,心裡想的是「這檔不可能漲那麼多吧」——那句話本身就是問題。合理性檢查的邊界該來自「物理上不可能」(例如價格不可能是負的、不可能一夜十倍),而不是來自我對行情的預測。

我踩過的坑(本篇即坑,再補一刀)

事後檢討時我發現,其實事故前就有徵兆:備援源在更早幾天就回過一次奇怪的數值,只是那次剛好落在持倉之外的觀察清單股票上,沒人受害,我看到 log 裡的怪數字還想「免費源嘛,難免」。**「難免」是資料品質崩壞的第一聲敲門。**如果第一次看到怪值就加防護,後面根本不會有事故。異常不分有沒有造成損害,都值得一條規則。

小結+明日預告

這次事故的三條收穫:外部資料一律過合理性檢查、失敗時選最便宜的失敗方式(舊值 > 空值 > 假值)、資料新鮮度要一路透傳到人眼前。自動化系統最可怕的不是壞掉,是壞得很安靜

加上續集的第四條,我認為它比前三條都重要:**你加的每一道防呆,本身也是一個會壞的東西。**它的邊界要比它守護的訊號寬,而且那個關係最好寫成一條測試——因為人會忘記,測試不會。

順帶埋個伏筆:今天這道守在資料入口的合理區間檢查,本質上是一道「資料閘道」——不合格的東西進不了系統。第四週(Day 24)會再看到一模一樣的形狀,只是守的對象從「外部資料」換成「我自己寫的設定」。閘道這個模式,值得在系統的每個入口都放一道。

聊完錢的防呆,明天換個場景聊「家」:台灣夏天濕度爬到 70% 以上會發霉的牆,和一套會自己把除濕機打開、乾了再關掉的 Home Assistant × Agent 整合——它一開始只是發訊息提醒我,後來才長成閉環。


🔑 這篇的關鍵字
sanity check / 資料合理性驗證 · 兩道互補的檢查:相對變化率(擋單次跳動)+ 每檔絕對 {min,max}(擋連續抓到同一錯值,變化率為 0 會漏掉)· 失敗偏好排序:舊值 > 空值 > 假值 · stale 旗標一路透傳到報告層(新鮮度本身就是資料)· 拒收要留痕、要告警 · 不變量測試:護欄邊界必須寬於它守護的訊號門檻


我是一名金融業資訊工程師,這是我半年來在家自架 AI Agent 系統的實錄。


上一篇
Day 17:睡醒就有投資晨報——從快取到 Telegram 的最後一哩
下一篇
Day 19:濕度過高自動開除濕機——HA × Agent 從「提醒」到「閉環」
系列文
生活中的 AI 應用:我在家用 NAS 養了一隻 Agent,幫我看盤、顧家、盯備考——30 天自架實錄22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言